21.5.1 单元测试#
测试框架配置#
// jest.config.ts export default { preset: 'ts-jest', testEnvironment: 'node', roots: ['<rootDir>/src'], testMatch: ['/tests//.ts', '**/?(.)+(spec|test).ts'], collectCoverageFrom: [ 'src//*.ts', '!src//.d.ts', '!src/**/.test.ts', '!src/**/*.spec.ts' ], coverageThreshold: { global: { branches: 80, functions: 80,
lines: 80, statements: 80 } }, moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' } };
基本单元测试#
bashtypescript // __tests__/plugin.test.ts import { MyPlugin } from '../src/plugin'; describe('MyPlugin', () => { let plugin: MyPlugin; beforeEach(() => { plugin = new MyPlugin(); }); afterEach(async () => { try { await plugin.cleanup(); } catch (error) { // 忽略清理错误 } }); describe('initialization', () => { test('should initialize with correct configuration', async () => { await plugin.initialize({ name: 'test-plugin', version: '1.0.0', description: 'Test plugin' }); const info = plugin.getInfo(); expect(info.name).toBe('test-plugin'); expect(info.version).toBe('1.0.0'); }); test('should throw error if already initialized', async () => { await plugin.initialize({}); await expect(plugin.initialize({})).rejects.toThrow(); }); }); describe('lifecycle', () => { test('should start after initialization', async () => { await plugin.initialize({}); await plugin.start(); const status = plugin.getStatus(); expect(status.enabled).toBe(true); }); test('should stop after starting', async () => { await plugin.initialize({}); await plugin.start(); await plugin.stop(); const status = plugin.getStatus(); expect(status.enabled).toBe(false); }); }); }); ### 工具测试 // __tests__/tools/greeting.test.ts import { GreetingTool } from '../../src/tools/greeting'; describe('GreetingTool', () => { let tool: GreetingTool; beforeEach(() => { tool = new GreetingTool(); }); describe('execute', () => { test('should generate English greeting', async () => { const result = await tool.execute( { name: 'World', language: 'english' }, {} ); expect(result.success).toBe(true); expect(result.data.greeting).toBe('Hello, World!'); expect(result.data.language).toBe('english'); }); test('should generate Chinese greeting', async () => { const result = await tool.execute( { name: 'World', language: 'chinese' }, {} ); expect(result.success).toBe(true); expect(result.data.greeting).toBe('你好,World!'); }); test('should handle invalid language gracefully', async () => { const result = await tool.execute( { name: 'World', language: 'invalid' }, {} ); expect(result.success).toBe(true); expect(result.data.greeting).toBe('Hello, World!'); // 默认英语 }); }); describe('validate', () => { test('should validate required parameters', () => { const result = tool.validate({}); expect(result.valid).toBe(false); expect(result.errors).toContain('Missing required parameter: name'); }); test('should validate parameter types', () => { const result = tool.validate({ name: 123 }); expect(result.valid).toBe(false); expect(result.errors).toContain('Parameter name must be a string'); }); test('should pass valid parameters', () => { const result = tool.validate({ name: 'World' }); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); }); });
命令测试#
bashtypescript // __tests__/commands/greet.test.ts import { GreetCommand } from '../../src/commands/greet'; describe('GreetCommand', () => { let command: GreetCommand; beforeEach(() => { command = new GreetCommand(); }); describe('execute', () => { test('should greet informally by default', async () => { const result = await command.execute( ['--name', 'World'], {} ); expect(result.success).toBe(true); expect(result.output).toContain('Hey, World!'); }); test('should greet formally with flag', async () => { const result = await command.execute( ['--name', 'World', '--formal'], {} ); expect(result.success).toBe(true); expect(result.output).toContain('Good day, World.'); }); test('should handle missing name parameter', async () => { const result = await command.execute([], {}); expect(result.success).toBe(false); expect(result.error).toBeDefined(); }); }); describe('parseArgs', () => { test('should parse long arguments', () => { const parsed = command.parseArgs(['--name', 'World', '--formal']); expect(parsed.name).toBe('World'); expect(parsed.formal).toBe(true); }); test('should parse short arguments', () => { const parsed = command.parseArgs(['-n', 'World', '-f']); expect(parsed.name).toBe('World'); expect(parsed.formal).toBe(true); }); test('should use default values', () => { const parsed = command.parseArgs(['--name', 'World']); expect(parsed.name).toBe('World'); expect(parsed.formal).toBe(false); }); }); describe('help', () => { test('should generate help text', () => { const help = command.help(); expect(help).toContain('Command: greet'); expect(help).toContain('Description:'); expect(help).toContain('Usage:'); expect(help).toContain('Options:'); }); }); }); ### 钩子测试 // __tests__/hooks/logging.test.ts import { LoggingHook } from '../../src/hooks/logging'; describe('LoggingHook', () => { let hook: LoggingHook; let consoleLogSpy: jest.SpyInstance; beforeEach(() => { hook = new LoggingHook(); consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); }); afterEach(() => { consoleLogSpy.mockRestore(); }); describe('execute', () => { test('should log command execution', async () => { const event = { type: 'before_command', data: { command: 'greet', args: ['--name', 'World'] }, timestamp: new Date() }; const result = await hook.execute(event, {}); expect(result.success).toBe(true); expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining('Executing command: greet') ); }); test('should not prevent default behavior', async () => { const event = { type: 'before_command', data: {}, timestamp: new Date() }; const result = await hook.execute(event, {}); expect(result.success).toBe(true); expect(result.preventDefault).toBeUndefined(); }); }); });
21.5.2 集成测试#
插件集成测试#
bashtypescript // __tests__/integration/plugin.integration.test.ts import { MyPlugin } from '../../src/plugin'; describe('MyPlugin Integration', () => { let plugin: MyPlugin; beforeEach(async () => { plugin = new MyPlugin(); await plugin.initialize({}); }); afterEach(async () => { try { await plugin.stop(); await plugin.cleanup(); } catch (error) { // 忽略清理错误 } }); describe('full lifecycle', () => { test('should complete full lifecycle', async () => { // 启动插件 await plugin.start(); // 验证插件运行中 let status = plugin.getStatus(); expect(status.enabled).toBe(true); // 停止插件 await plugin.stop(); // 验证插件已停止 status = plugin.getStatus(); expect(status.enabled).toBe(false); // 清理插件 await plugin.cleanup(); }); }); describe('tool integration', () => { test('should execute tool through plugin', async () => { await plugin.start(); const result = await plugin.toolManager.execute( 'greeting', { name: 'World' }, {} ); expect(result.success).toBe(true); expect(result.data.greeting).toBeDefined(); }); }); describe('command integration', () => { test('should execute command through plugin', async () => { await plugin.start(); const result = await plugin.commandManager.execute( 'greet', ['--name', 'World'], {} ); expect(result.success).toBe(true); expect(result.output).toBeDefined(); }); }); describe('hook integration', () => { test('should execute hooks through plugin', async () => { await plugin.start(); const event = { type: 'before_command', data: { command: 'greet', args: ['--name', 'World'] }, timestamp: new Date() }; const result = await plugin.hookManager.execute( 'before_command', event, {} ); expect(result.success).toBe(true); }); }); }); ### 端到端测试 // __tests__/e2e/plugin.e2e.test.ts import { MyPlugin } from '../../src/plugin'; describe('MyPlugin E2E', () => { let plugin: MyPlugin; beforeEach(async () => { plugin = new MyPlugin(); await plugin.initialize({}); await plugin.start(); }); afterEach(async () => { try { await plugin.stop(); await plugin.cleanup(); } catch (error) { // 忽略清理错误 } }); test('should handle complete workflow', async () => { // 1. 执行工具 const toolResult = await plugin.toolManager.execute( 'greeting', { name: 'World' }, {} ); expect(toolResult.success).toBe(true); // 2. 执行命令 const commandResult = await plugin.commandManager.execute( 'greet', ['--name', 'World'], {} ); expect(commandResult.success).toBe(true); // 3. 验证插件状态 const status = plugin.getStatus(); expect(status.enabled).toBe(true); }); test('should handle errors gracefully', async () => { // 执行无效工具 const result = await plugin.toolManager.execute( 'invalid-tool', {}, {} ); expect(result.success).toBe(false); expect(result.error).toBeDefined(); // 验证插件仍然运行 const status = plugin.getStatus(); expect(status.enabled).toBe(true); }); });
21.5.3 测试工具和辅助函数#
Mock 工具#
bashtypescript // __tests__/utils/mocks.ts import { PluginContext } from '@claude-code/plugin-sdk'; /** * 创建 Mock 插件上下文 */ export function createMockContext(): PluginContext { return { getService: jest.fn(), setService: jest.fn(), getData: jest.fn(), setData: jest.fn(), removeData: jest.fn(), clearData: jest.fn() }; } /** * 创建 Mock 工具管理器 */ export function createMockToolManager() { return { register: jest.fn(), unregister: jest.fn(), getTool: jest.fn(), getAllTools: jest.fn(), execute: jest.fn() }; } /** * 创建 Mock 命令管理器 */ export function createMockCommandManager() { return { register: jest.fn(), unregister: jest.fn(), getCommand: jest.fn(), getAllCommands: jest.fn(), execute: jest.fn() }; } /** * 创建 Mock 钩子管理器 */ export function createMockHookManager() { return { register: jest.fn(), unregister: jest.fn(), getHooks: jest.fn(), getAllHooks: jest.fn(), execute: jest.fn() }; } ### 测试辅助函数 // __tests__/utils/helpers.ts import { MyPlugin } from '../../src/plugin'; /** * 创建测试插件实例 */ export async function createTestPlugin(): Promise<MyPlugin> { const plugin = new MyPlugin(); await plugin.initialize({}); return plugin; } /** * 创建并启动测试插件 */ export async function createAndStartTestPlugin(): Promise<MyPlugin> { const plugin = await createTestPlugin(); await plugin.start(); return plugin; } /** * 清理测试插件 */ export async function cleanupTestPlugin(plugin: MyPlugin): Promise<void> { try { await plugin.stop(); await plugin.cleanup(); } catch (error) { // 忽略清理错误 } } /** * 等待异步操作完成 */ export function waitFor(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } /** * 重试函数 */ export async function retry<T>( fn: () => Promise<T>, maxRetries: number = 3, delay: number = 100 ): Promise<T> { let lastError: Error; for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { lastError = error; if (i < maxRetries - 1) { await waitFor(delay); } } } throw lastError; }
测试断言#
bashtypescript // __tests__/utils/assertions.ts import { ToolResult, CommandResult } from '@claude-code/plugin-sdk'; /** * 断言工具结果成功 */ export function expectToolSuccess(result: ToolResult) { expect(result.success).toBe(true); expect(result.error).toBeUndefined(); } /** * 断言工具结果失败 */ export function expectToolFailure(result: ToolResult) { expect(result.success).toBe(false); expect(result.error).toBeDefined(); } /** * 断言命令结果成功 */ export function expectCommandSuccess(result: CommandResult) { expect(result.success).toBe(true); expect(result.error).toBeUndefined(); expect(result.exitCode).toBe(0); } /** * 断言命令结果失败 */ export function expectCommandFailure(result: CommandResult) { expect(result.success).toBe(false); expect(result.error).toBeDefined(); expect(result.exitCode).not.toBe(0); } /** * 断言结果包含数据 */ export function expectResultData(result: ToolResult | CommandResult) { expect(result.data).toBeDefined(); expect(Object.keys(result.data).length).toBeGreaterThan(0); } ## 21.5.4 调试技巧 ### 使用 VS Code 调试 // .vscode/launch.json { "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "Debug Plugin", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen" }, { "type": "node", "request": "launch", "name": "Debug Tests", "runtimeExecutable": "npm", "runtimeArgs": ["run", "test:watch"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen" }, { "type": "node", "request": "launch", "name": "Debug Current Test", "runtimeExecutable": "npm", "runtimeArgs": ["test", "--", "${fileBasenameNoExtension}"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen" } ] }
日志调试#
bashtypescript // src/utils/logger.ts export class DebugLogger { private enabled: boolean; constructor(enabled: boolean = process.env.DEBUG === 'true') { this.enabled = enabled; } log(message: string, data?: any): void { if (!this.enabled) { return; } console.log(`[DEBUG] ${message}`, data || ''); } error(message: string, error?: Error): void { console.error(`[ERROR] ${message}`, error || ''); } trace(message: string, data?: any): void { if (!this.enabled) { return; } console.trace(`[TRACE] ${message}`, data || ''); } } // 使用示例 const logger = new DebugLogger(); export class MyPlugin extends Plugin { async initialize(config: PluginConfig): Promise<void> { logger.log('Initializing plugin', { config }); try { // 初始化逻辑 logger.log('Plugin initialized successfully'); } catch (error) { logger.error('Failed to initialize plugin', error); throw error; } } } ### 性能分析 // src/utils/profiler.ts export class Profiler { private measurements: Map<string, number[]> = new Map(); /** * 测量函数执行时间 */ async measure<T>(name: string, fn: () => Promise<T>): Promise<T> { const start = Date.now(); try { return await fn(); } finally { const duration = Date.now() - start; if (!this.measurements.has(name)) { this.measurements.set(name, []); } this.measurements.get(name)!.push(duration); } } /** * 获取测量结果 */ getStats(name: string) { const measurements = this.measurements.get(name); if (!measurements || measurements.length === 0) { return null; } const sum = measurements.reduce((a, b) => a + b, 0); const avg = sum / measurements.length; const min = Math.min(...measurements); const max = Math.max(...measurements); return { count: measurements.length, sum, avg, min, max }; } /** * 打印所有统计信息 */ printStats(): void { for (const [name, measurements] of this.measurements.entries()) { const stats = this.getStats(name); console.log(`[Profiler] ${name}:`, stats); } } } // 使用示例 const profiler = new Profiler(); export class MyPlugin extends Plugin { async executeTool(name: string, params: any): Promise<ToolResult> { return profiler.measure(`tool.${name}`, async () => { return this.toolManager.execute(name, params, {}); }); } }
错误追踪#
bashtypescript // src/utils/error-tracker.ts export class ErrorTracker { private errors: Error[] = []; /** * 追踪错误 */ track(error: Error): void { this.errors.push(error); console.error('[ErrorTracker]', error); } /** * 获取所有错误 */ getErrors(): Error[] { return [...this.errors]; } /** * 清除错误 */ clear(): void { this.errors = []; } /** * 获取错误统计 */ getStats() { const errorTypes = new Map<string, number>(); for (const error of this.errors) { const type = error.constructor.name; errorTypes.set(type, (errorTypes.get(type) || 0) + 1); } return { total: this.errors.length, types: Object.fromEntries(errorTypes) }; } } // 使用示例 const errorTracker = new ErrorTracker(); export class MyPlugin extends Plugin { async executeTool(name: string, params: any): Promise<ToolResult> { try { return await this.toolManager.execute(name, params, {}); } catch (error) { errorTracker.track(error); throw error; } } } ## 21.5.5 测试最佳实践 ### 1. 测试命名 // 好的测试命名 describe('GreetingTool', () => { describe('execute', () => { test('should generate English greeting when language is English', async () => { // 测试代码 }); test('should generate Chinese greeting when language is Chinese', async () => { // 测试代码 }); }); }); // 不好的测试命名 describe('GreetingTool', () => { test('test1', async () => { // 测试代码 }); test('test2', async () => { // 测试代码 }); });
2. 测试隔离#
bashtypescript // 每个测试都应该独立运行 describe('MyPlugin', () => { let plugin: MyPlugin; beforeEach(() => { // 每个测试前创建新实例 plugin = new MyPlugin(); }); afterEach(async () => { // 每个测试后清理 await plugin.cleanup(); }); test('test 1', async () => { // 不依赖其他测试 }); test('test 2', async () => { // 不依赖其他测试 }); }); ### 3. 测试覆盖率 // 确保测试覆盖所有代码路径 describe('GreetingTool', () => { describe('execute', () => { test('should handle English language', async () => { // 覆盖 English 分支 }); test('should handle Chinese language', async () => { // 覆盖 Chinese 分支 }); test('should handle Spanish language', async () => { // 覆盖 Spanish 分支 }); test('should handle unknown language', async () => { // 覆盖默认分支 }); }); });
4. 测试速度#
bashtypescript // 使用 Mock 加速测试 describe('MyPlugin', () => { test('should execute tool quickly', async () => { // Mock 工具管理器 const mockToolManager = createMockToolManager(); mockToolManager.execute.mockResolvedValue({ success: true, data: { result: 'mocked' } }); plugin.toolManager = mockToolManager; // 快速执行测试 const result = await plugin.executeTool('test', {}); expect(result.success).toBe(true); }); }); ### 5. 测试可维护性 // 使用辅助函数提高可维护性 describe('MyPlugin', () => { test('should handle multiple tool executions', async () => { const tools = ['tool1', 'tool2', 'tool3']; for (const tool of tools) { const result = await plugin.executeTool(tool, {}); expectToolSuccess(result); } }); });
21.5.6 运行测试#
运行所有测试#
bashbash # 运行所有测试 npm test # 运行测试并生成覆盖率报告 npm run test -- --coverage # 监听模式 npm run test:watch ### 运行特定测试 # 运行特定测试文件 npm test -- plugin.test.ts # 运行特定测试套件 npm test -- --testNamePattern="MyPlugin" # 运行特定测试 npm test -- --testNamePattern="should initialize successfully"
测试覆盖率#
bashbash # 生成覆盖率报告 npm run test -- --coverage # 查看覆盖率报告 open coverage/lcov-report/index.html # 设置覆盖率阈值 npm run test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}' ### CI/CD 集成 # .github/workflows/test.yml name: Tests on: push: branches: [ main, develop ] pull_request: branches: [ main, develop ] jobs: test: runs-on: ubuntu-latest strategy: matrix: node-version: [14.x, 16.x, 18.x] steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Generate coverage run: npm run test -- --coverage - name: Upload coverage uses: codecov/codecov-action@v2 with: files: ./coverage/lcov.info